package com.opennews.openplatform

import com.opennews.openplatform.constants.RoleConstant
import grails.compiler.GrailsCompileStatic
import grails.gorm.DetachedCriteria
import grails.gorm.transactions.Transactional
import groovy.transform.CompileDynamic
import org.hibernate.criterion.CriteriaSpecification
import org.hibernate.sql.JoinType
import org.springframework.beans.factory.annotation.Value
import org.springframework.security.crypto.factory.PasswordEncoderFactories

import java.nio.file.Paths

@Transactional(readOnly = true)
@GrailsCompileStatic
class UserService {
    @Value('${openplatform.resource.upload.rootFilePath}')
    String rootFilePath

    @Value('${openplatform.resource.httpBaseUrl}')
    String httpBaseUrl

    @Value('${openplatform.resource.avatarDirectoryName}')
    String avatarDirectoryName

    @Value('${openplatform.resource.defaultAvatarUrl}')
    String defaultAvatarUrl

    UserActiveTokenService userActiveTokenService
    UserRoleService userRoleService

    /**
     * Masks username and avoids sensitive data revealing.
     * @param username
     * @return
     */
    String maskUsername(String username) {
        return "${username.substring(0, 3)}****${username.substring(username.length() - 4)}"
    }

    /**
     * Validates if the presentedPassword matches the user stored password.
     * @param username: The string of the username.
     * @param presentedPassword: The string of the user provided password.
     * @return True means password matches. False means not.
     */
    boolean validatePassword(String username, String presentedPassword) {
        def encodedPassword = User.where {
            username == username
        }.projections {
            property("password")
        }.get() as String

        if (!SharedUtil.isNullOrEmpty(encodedPassword)) {
            return PasswordEncoderFactories.createDelegatingPasswordEncoder().matches(presentedPassword, encodedPassword)
        } else {
            return false
        }
    }

    @CompileDynamic
    Map<String, ?> queryStatus(String username) {
        return User.createCriteria().get {
            // Transforms the result data format to Map instance.
            resultTransformer(CriteriaSpecification.ALIAS_TO_ENTITY_MAP)

            eq("username", username)

            projections {
                property("id", "id")
                property("username", "username")
                property("enabled", "enabled")
                property("accountExpired", "accountExpired")
                property("accountLocked", "accountLocked")
                property("passwordExpired", "passwordExpired")
            }
        } as Map<String, ?>
    }

    /**
     * Checks if the username already exists.
     * @param username: The username which will be checked of its existing.
     * @return True means the username exists. False means not.
     */
    boolean checkOnUsername(String username) {
        return User.countByUsername(username) > 0
    }

    /**
     * Gets user avatar url based on its filename.
     * @param avatarFilename: The avatar filename of the uploaded file.
     * @return The full url of user avatar.
     */
    String getAvatarUrl(String avatarFilename) {
        if (avatarFilename == null) {
            avatarFilename = ""
        }

        def avatarFileFullName = Paths.get(rootFilePath, avatarDirectoryName, avatarFilename).toString()

        if (SharedUtil.isNullOrEmpty(avatarFilename) || !(new File(avatarFileFullName).exists())) {
            return defaultAvatarUrl
        } else {
            return "$httpBaseUrl/$avatarDirectoryName/${avatarFilename}"
        }
    }

    /**
     * Queries the user details by id. This is for query account group user details.
     * @param accountGroupId: The string of user account group id.
     * @param id: The string of id.
     * @return The map instance which contains keys below.
     *          accountGroupId: String of user account group.
     *          id: String of user id.
     *          username: String of username.
     *          fullName: String of user full name.
     *          avatarUrl: String of user avatar url.
     *          description: String of user description.
     *          isAccountGroupCreator: Bool indicates if the user is account group registerer.
     *          enabled: Bool indicates if the user is enabled or disabled.
     *          fullRoles: Data returned by userRoleService.queryRoleWithIdAndTitles().
     *          dateCreated: Date of the data created.
     *          lastUpdated: Date of the data updated.
     */
    @CompileDynamic
    Map<String, ?> queryDetails(String accountGroupId, String idOrUsername) {
        // Queries the user details by id or username.
        def userDetails = AccountGroupUser.createCriteria().get {
            // Transforms the result data format to Map instance.
            resultTransformer(CriteriaSpecification.ALIAS_TO_ENTITY_MAP)

            // Sets the join type for user property.
            createAlias("user", "user", JoinType.INNER_JOIN)

            // Sets the query condition.
            and {
                or {
                    eq("user.id", idOrUsername)
                    eq("user.username", idOrUsername)
                }

                // The input accountGroupId might not be user's current accountGroupId.
                // That's why we need to check on AccountGroupUser.accountGroup.id instead of User.accountGroup.id
                eq("accountGroup.id", accountGroupId)
            }

            // Specifies the property we need.
            projections {
                property("accountGroup.id", "accountGroupId")
                property("user.id", "id")
                property("user.username", "username")
                property("user.fullName", "fullName")
                property("user.avatarFilename", "avatarFilename")
                property("user.description", "description")
                property("user.enabled", "enabled")
                property("user.dateCreated", "dateCreated")
                property("user.lastUpdated", "lastUpdated")
            }
        } as Map<String, ?>

        return fulfillDetails(accountGroupId, userDetails)
    }

    /**
     * Queries the user details by id. It is for authentication usage.
     * @param id: The string of id.
     * @return The map instance which contains keys below.
     *          accountGroupId: String of user account group.
     *          id: String of user id.
     *          username: String of username.
     *          fullName: String of user full name.
     *          avatarUrl: String of user avatar url.
     *          description: String of user description.
     *          enabled: Bool indicates if the user is enabled or disabled.
     *          fullRoles: Data returned by userRoleService.queryRoleWithIdAndTitles().
     *          dateCreated: Date of the data created.
     *          lastUpdated: Date of the data updated.
     */
    @CompileDynamic
    Map<String, ?> queryDetails(String idOrUsername) {
        // Queries the user details by id or username.
        def userDetails = User.createCriteria().get {
            // Transforms the result data format to Map instance.
            resultTransformer(CriteriaSpecification.ALIAS_TO_ENTITY_MAP)

            // Sets the query condition.
            or {
                eq("id", idOrUsername)
                eq("username", idOrUsername)
            }

            // Specifies the property we need.
            projections {
                // User might belong to multiple account groups. Join AccountGroupUser leads multiple user results.
                // That's why distinct is needed.
                distinct("accountGroup.id", "accountGroupId")
                property("id", "id")
                property("username", "username")
                property("fullName", "fullName")
                property("avatarFilename", "avatarFilename")
                property("description", "description")
                property("enabled", "enabled")
                property("dateCreated", "dateCreated")
                property("lastUpdated", "lastUpdated")
            }
        } as Map<String, ?>

        return fulfillDetails(null, userDetails)
    }

    /**
     * Queries the user ids by usernames.
     * @param usernames: The string list of the usernames to query ids.
     * @return The string list of the user ids.
     */
    @CompileDynamic
    List<String> queryUserIds(List<String> usernames) {
        if (usernames?.size() > 0) {
            return User.createCriteria().list {
                // Sets the query condition.
                'in'("username", usernames)

                // Specifies the property we need.
                projections {
                    property("id")
                }
            }
        }

        return []
    }

    /**
     * Queries the user id by username.
     * @param username: The string of the username to query its id.
     * @return The string of the user id.
     */
    String queryUserId(String username) {
        return queryUserIds([username])?.first()
    }

    /**
     * Tries to query user basic info. The original value returned If user not found.
     * @param accountGroupId: String of user account group id.
     * @param usernames: String list of usernames.
     * @return The map instance which contains keys below.
     *          id: String of user id.
     *          username: String of user username.
     *          fullName: String of user full name.
     *          avatarUrl: String of user avatar url.
     */
    @CompileDynamic
    List<Map<String, ?>> queryBasicInfo(String accountGroupId, List<String> usernames) {
        usernames = usernames?.unique()
        List<Map<String, ?>> result = []

        if (usernames?.size() > 0) {
            // Queries the user basic details by usernames.
            def userDetails = User.createCriteria().list {
                // Transforms the result data format to Map instance.
                resultTransformer(CriteriaSpecification.ALIAS_TO_ENTITY_MAP)

                // Sets the query condition.
                and {
                    'in'("username", usernames)
                    eq("accountGroup.id", accountGroupId)
                }

                // Specifies the property we need.
                projections {
                    property("id", "id")
                    property("username", "username")
                    property("fullName", "fullName")
                    property("avatarFilename", "avatarFilename")
                }
            } as List<Map<String, ?>>

            Map<String, ?> item

            usernames.each { String username ->
                item = userDetails.find { Map<String, ?> user ->
                    user.username == username
                } as Map<String, ?>

                if (item != null) {
                    result << [
                        id: item.id,
                        username: item.username,
                        fullName: item.fullName,
                        avatarUrl: getAvatarUrl(item.avatarFilename.toString())
                    ]
                } else {
                    result << [
                        id: "",
                        username: username,
                        fullName: username,
                        avatarUrl: getAvatarUrl("")
                    ]
                }
            }
        }

        return result
    }

    /**
     * Queries users who are not in the provided account group.
     * @param accountGroupId: String of user account group id.
     * @param keyword: The string of the user account group id.
     * @param pageSize: How many records is return in one trip.
     * @param pageIndex: Where to take rows.
     * @return The map instance which contains keys below.
     *          id: String of user id.
     *          username: String of username.
     *          fullName: String of user full name.
     *          avatarUrl: String of user avatar url.
     */
    @CompileDynamic
    List<Map<String, ?>> queryUsersNotInAccountGroup(String accountGroupId, String keyword, Integer pageSize, Integer pageIndex) {
        // We have to exclude these users:
        // - The owner of this account group.
        // - The user has this account group as his/her current account group.
        // - The user has this account group as NOT his/her current account group.

        // Queries all users which belongs to this account group.
        def accountGroupCriteria = new DetachedCriteria(AccountGroupUser).build {
            eq("accountGroup.id", accountGroupId)

            projections {
                property("user.id")
            }
        }

        return AccountGroupUser.createCriteria().list {
            // Transforms the result data format to Map instance.
            resultTransformer(CriteriaSpecification.ALIAS_TO_ENTITY_MAP)

            // Sets the join type for user property in order to get its properties.
            createAlias("user", "user", JoinType.INNER_JOIN)

            // Sets the query condition.
            // Finds all account group creators with username like and not in the account group.
            and {
                like("user.username", "$keyword%")
                eq("isAccountGroupCreator", true)
                not {'in'("user.id", accountGroupCriteria)}
            }

            // Specifies the property we need.
            projections {
                property("user.id", "id")
                property("user.username", "username")
                property("user.fullName", "fullName")
                property("user.avatarFilename", "avatarFilename")
            }

            // How many rows are return for this trip.
            maxResults(pageSize)

            // Where to take the rows.
            firstResult(pageSize * pageIndex)
        }.each { Map<String, ?> p ->
            p.username = maskUsername(p.username.toString())
            p.avatarUrl = getAvatarUrl(p.avatarFilename.toString())
        }
    }

    /**
     * Queries account group users by account group id.
     * @param accountGroupId: The string of the user account group id.
     * @param pageSize: How many records is return in one trip.
     * @param pageIndex: Where to take rows.
     * @return The map instance which contains keys below.
     *          id: String of user id.
     *          username: String of username.
     *          fullName: String of user full name.
     *          avatarUrl: String of user avatar url.
     *          enabled: Bool indicates if the user is enabled or disabled.
     *          isAccountGroupCreator: Bool indicates if the user is account group registerer.
     *          isAccountGroupAdmin: Bool indicates if the user is account group admin.
     */
    @CompileDynamic
    List<Map<String, ?>> queryAccountGroupUsers(String accountGroupId, Integer pageSize, Integer pageIndex) {
        // Queries the user list which does not contains the provided username.
        return AccountGroupUser.createCriteria().list {
            // Transforms the result data format to Map instance.
            resultTransformer(CriteriaSpecification.ALIAS_TO_ENTITY_MAP)

            // Sets the join type for user property in order to get its properties.
            createAlias("user", "user", JoinType.INNER_JOIN)

            // Sets the query condition.
            eq("accountGroup.id", accountGroupId)

            // Specifies the property we need.
            projections {
                property("user.id", "id")
                property("user.username", "username")
                property("user.fullName", "fullName")
                property("user.avatarFilename", "avatarFilename")
                property("user.enabled", "enabled")
                property("isAccountGroupCreator", "isAccountGroupCreator")
            }

            // How many rows are return for this trip.
            maxResults(pageSize)

            // Where to take the rows.
            firstResult(pageSize * pageIndex)

            order("user.enabled", "desc")
        }.each { Map<String, ?> p ->
            p.avatarUrl = getAvatarUrl(p.avatarFilename.toString())
            p.isAccountGroupAdmin = userRoleService.hasRole(accountGroupId, p.id.toString(), RoleConstant.ROLE_ACCOUNT_GROUP_ADMIN)
        }
    }

    /**
     * Counts the account group user.
     * @param accountGroupId: The string of the account group.
     * @return The count number of the user in this account group.
     */
    Integer countByAccountGroup(String accountGroupId) {
        return User.countByAccountGroup(AccountGroup.proxy(accountGroupId))
    }

    /**
     * Restores this user current account group to his/her own one.
     * @param userId: String of user id.
     */
    @CompileDynamic
    void restoreAccountGroupByUserId(String userId) {
        // Sets user's own account group as default.
        User.where {
            id == userId
        }.updateAll([
            accountGroup: AccountGroupUser.where {
                user == User.proxy(userId) &&
                    isAccountGroupCreator == true
            }.projections {
                property("accountGroup")
            }.get()
        ])
    }

    /**
     * Account group with the input id is about to be deleted.
     * All users with this account group id as current one will be restored to their own account group id.
     * @param accountGroupId: String of account group id which is about to be deleted.
     */
    void restoreAccountGroupByAccountGroupId(String accountGroupId) {
        def hql = """
                        UPDATE
                            User as otherUser
                        SET
                            accountGroup = (
                                SELECT
                                    accountGroup.id
                                FROM
                                    AccountGroupUser
                                WHERE
                                    user.id = otherUser.id AND
                                    isAccountGroupCreator = :isAccountGroupCreator
                            )
                        WHERE
                            accountGroup.id = :accountGroupId
                      """

        User.executeUpdate(hql, [
            accountGroupId: accountGroupId,
            isAccountGroupCreator: true
        ])
    }

    /**
     * Updates user info based on provided map data and username.
     * The map keys must match user property names.
     * @param username: The string of the username whose data will be updated.
     * @param data: The map which contains keys with same name as user properties.
     */
    @Transactional
    void updateCurrentUser(String username, Map<String, ?> data) {
        User.where {
            username == username
        }.updateAll([
            fullName: data.fullName,
            description: data.description,
        ])
    }

    /**
     * Updates user related roles.
     * @param accountGroupId: The string of current user account group id.
     * @param data: The map which contains keys with same name as user properties.
     */
    @Transactional
    void updateRoles(String accountGroupId, Map<String, ?> data) {
        def userId = data.id.toString()
        def username = data.username.toString()

        // Makes sure current user and parameter user belong to same user account group.
        // Makes sure account group owner's role cannot be changed.
        if (AccountGroupUser.countByAccountGroupAndUser(AccountGroup.proxy(accountGroupId), User.proxy(userId)) == 1 &&
            AccountGroupUser.countByAccountGroupAndUserAndIsAccountGroupCreator(AccountGroup.proxy(accountGroupId), User.proxy(userId), true) == 0) {
            def fullRoles = data.fullRoles as List<Map<String, ?>>

            // If the user roles is set.
            if (fullRoles?.size() > 0) {
                // Gets the role id list from the posted data.
                def roleIds = SharedUtil.<String> mapListMap(fullRoles, "id")

                // Queries user current role ids.
                def currentRoleIds = userRoleService.queryRoleIds(accountGroupId, userId)

                // Gets all new added role ids.
                def addedRoleIds = roleIds - currentRoleIds

                // Gets all deleted role ids.
                def deletedRoleIds = currentRoleIds - roleIds

                // Adds new roles if needed.
                if (addedRoleIds?.size() > 0) {
                    userRoleService.add(accountGroupId, userId, addedRoleIds)
                }

                // Deletes roles if needed.
                if (deletedRoleIds?.size() > 0) {
                    userRoleService.delete(accountGroupId, userId, deletedRoleIds)
                }

                // If role has been changed we need to make user re-login by deleting active tokens.
                if (addedRoleIds?.size() > 0 || deletedRoleIds?.size() > 0) {
                    userActiveTokenService.deleteByUserAndAccountGroup(username, accountGroupId)
                }
            }
        }
    }

    /**
     * Updates the password for the user with provided username.
     * @param currentUserAccountGroupId: The string of current user account group id.
     * @param inputUsername: The string of the input username.
     * @param password: The string of the password.
     */
    @Transactional
    @CompileDynamic
    void updatePassword(String currentUserAccountGroupId, String inputUsername, String password) {
        // beforeUpdate method in User only triggered by save method.
        // That's why we need to encode password manually.
        User.where {
            username == inputUsername &&
                accountGroup == AccountGroup.proxy(currentUserAccountGroupId)
        }.updateAll([
            password: PasswordEncoderFactories.createDelegatingPasswordEncoder().encode(password)
        ])
    }

    /**
     * Fulfills queried user data with extra query.
     * @param accountGroupId: The string of the user account group id.
     * @param userDetails: The details data queried from db.
     * @return The userDetails with fulfilled extra details.
     */
    private Map<String, ?> fulfillDetails(String accountGroupId, Map<String, ?> userDetails) {
        if (userDetails != null) {
            // Sets the avatar url based on its filename.
            userDetails.put("avatarUrl", getAvatarUrl(userDetails.avatarFilename.toString()))

            // Checks if current account group creator.
            def accountGroup = accountGroupId == null ? AccountGroup.proxy(userDetails.accountGroupId.toString()) : AccountGroup.proxy(accountGroupId)
            userDetails.put("isAccountGroupCreator", AccountGroupUser.countByAccountGroupAndUserAndIsAccountGroupCreator(accountGroup, User.proxy(userDetails.id.toString()), true) > 0)

            // Queries user role titles.
            userDetails.put("fullRoles", userRoleService.queryFullRoles(userDetails.accountGroupId.toString(), userDetails.id.toString()))
        }

        return userDetails
    }
}